package builder

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	namepkg "github.com/google/go-containerregistry/pkg/name"
	v1 "github.com/google/go-containerregistry/pkg/v1"
	"github.com/google/go-containerregistry/pkg/v1/empty"
	"github.com/google/go-containerregistry/pkg/v1/mutate"
	"github.com/google/go-containerregistry/pkg/v1/remote"
	"github.com/google/go-containerregistry/pkg/v1/tarball"
)

// Options defines parameters for building and publishing an OCI image.
type Options struct {
	// Files are the paths to files that should be included in the image.
	Files []string
	// ImageRef is the image reference (including registry and tag).
	ImageRef string
	// Push controls whether the built image should be pushed to a registry.
	Push bool
	// LocalTarPath, if non-empty, writes the built image to the given path as a tarball.
	LocalTarPath string
	// BaseImage is the reference to the base image used for the final image.
	BaseImage string
}

// Build constructs an OCI image from the given options and performs the
// requested outputs (push and/or local tarball).
func Build(ctx context.Context, opts Options) error {
	img, err := buildImage(opts.Files, opts.BaseImage)
	if err != nil {
		return err
	}

	var ref namepkg.Reference
	if opts.ImageRef != "" {
		ref, err = namepkg.ParseReference(opts.ImageRef, namepkg.WeakValidation)
		if err != nil {
			return err
		}
	}

	if opts.Push {
		if ref == nil {
			return fmt.Errorf("image reference required for push")
		}
		if err := remote.Write(ref, img); err != nil {
			return err
		}
	}

	if opts.LocalTarPath != "" {
		if ref == nil {
			ref, err = namepkg.ParseReference("generated:latest", namepkg.WeakValidation)
			if err != nil {
				return err
			}
		}
		if err := tarball.WriteToFile(opts.LocalTarPath, ref, img); err != nil {
			return err
		}
	}

	return nil
}

// buildImage collects the specified files into a single layer and returns the
// resulting v1.Image, using the provided base image reference as the starting
// point.
func buildImage(files []string, baseRef string) (v1.Image, error) {
	tmpDir, err := os.MkdirTemp("", "oci-build-")
	if err != nil {
		return nil, err
	}
	defer os.RemoveAll(tmpDir)

	projectDir, err := os.Getwd()
	if err != nil {
		return nil, err
	}

	var sanitized []string
	for _, f := range files {
		abs, err := filepath.Abs(f)
		if err != nil {
			return nil, err
		}
		rel, err := filepath.Rel(projectDir, abs)
		if err != nil {
			return nil, err
		}
		rel = filepath.Clean(rel)
		if strings.HasPrefix(rel, "..") {
			rel = filepath.Base(abs)
		}
		dst := filepath.Join(tmpDir, rel)
		if err := copyFile(abs, dst); err != nil {
			return nil, err
		}
		sanitized = append(sanitized, filepath.ToSlash(rel))
	}

	layer, err := layerFromDir(tmpDir)
	if err != nil {
		return nil, err
	}

	var base v1.Image = empty.Image
	if baseRef != "" {
		ref, err := namepkg.ParseReference(baseRef, namepkg.WeakValidation)
		if err != nil {
			return nil, err
		}
		base, err = remote.Image(ref)
		if err != nil {
			return nil, err
		}
	}

	img, err := mutate.AppendLayers(base, layer)
	if err != nil {
		return nil, err
	}

	cfg, err := img.ConfigFile()
	if err != nil {
		return nil, err
	}
	cfg.Architecture = "amd64"
	cfg.OS = "linux"
	if len(sanitized) > 0 {
		cfg.Config.Entrypoint = []string{"/" + sanitized[0]}
	}
	img, err = mutate.ConfigFile(img, cfg)
	if err != nil {
		return nil, err
	}

	return img, nil
}

// layerFromDir creates a tar.gz layer from the provided directory.
func layerFromDir(dir string) (v1.Layer, error) {
	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gz)
	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(dir, path)
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		hdr, err := tar.FileInfoHeader(info, "")
		if err != nil {
			return err
		}
		hdr.Name = rel
		if err := tw.WriteHeader(hdr); err != nil {
			return err
		}
		f, err := os.Open(path)
		if err != nil {
			return err
		}
		if _, err := io.Copy(tw, f); err != nil {
			f.Close()
			return err
		}
		f.Close()
		return nil
	})
	if err != nil {
		tw.Close()
		gz.Close()
		return nil, err
	}
	tw.Close()
	gz.Close()
	return tarball.LayerFromOpener(func() (io.ReadCloser, error) {
		return io.NopCloser(bytes.NewReader(buf.Bytes())), nil
	})
}

// copyFile copies a file from src to dst.
func copyFile(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.Create(dst)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return err
	}
	return out.Close()
}
